Skip to content

Fix #185: revive exited apps on access via a real-state start primitive#186

Merged
max-tet merged 8 commits into
mainfrom
fix/clayde/revive-exited-apps-on-access
Jul 24, 2026
Merged

Fix #185: revive exited apps on access via a real-state start primitive#186
max-tet merged 8 commits into
mainfrom
fix/clayde/revive-exited-apps-on-access

Conversation

@ClaydeCode

@ClaydeCode ClaydeCode commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

Fixes #185.

Problem

With apps.lifecycle.pause_enabled=true, an app that is exited (not paused) was never revived on access — it 502-looped forever. The wake-on-access path dispatched on the stored app status: PAUSED woke via compose unpause, everything else via the start path. When an app exited out-of-band (core-upgrade converge stop, crash, OOM) while the database still said PAUSED, compose unpause errored container is not paused, the revive task crashed, and the app never started (first hit in production on shard c0p3x5, diagnostic 8aac62f1).

Fix

One idempotent revive primitive, start_app, that decides from the real container state (get_app_container_state via docker compose ps + docker inspect), not the stored status:

  • running → no-op (reconciles the db status if it disagrees)
  • pausedunpause (falls back to up -d if the stack can't be unpaused)
  • exited / created / missingup -d

It acts only on a revivable app (STOPPED/RUNNING/DOWN/PAUSED) and logs a warning whenever the db status and the real container state disagree (per the issue's follow-up comment about defensiveness). Both wake-on-access and the always-on control tick now call start_app only.

The @throttle decorator was changed from a single global timestamp to per-argument, so throttling one app's revive can no longer silently drop another app's wake — which is what forced the old wake path to special-case PAUSED to dodge the global throttle.

Recommended reading order

  1. shard_core/util/misc.py — throttle → per-argument
  2. shard_core/service/app_tools.pyget_app_container_state, start_app, _do_unpause/_compose_up/_mark_running
  3. shard_core/service/app_lifecycle.py — route wake + always-on through start_app
  4. agents.md — note the revive primitive
  5. tests: test_throttle.py, test_app_compose_pinning.py, test_app_lifecycle_pause.py, test_app_lifecycle.py

Review panel

Adversarial reviewer — 1 BLOCKING, addressed:

  • start_app dropped the old status allow-list; the always-on control tick could revive an app mid-uninstall/reinstall or in ERROR and recreate orphaned containers. Fixed (commit 9f03ca6): restored an explicit _REVIVABLE_STATUS gate (STOPPED/RUNNING/DOWN/PAUSED) with the real-state dispatch inside it; added test_start_app_skips_non_revivable_status.
  • Mixed paused+exited multi-container stack: unpause leaves the exited sibling down or errors, re-creating the 502-loop (advisory). Fixed: the paused branch now falls back to up -d if unpause errors, so it can't crash-loop; added test_start_app_falls_back_to_up_when_unpause_fails. (The mixed state itself is pathological — compose pause is atomic — so full multi-container reconciliation is left out per simplicity.)
  • docker inspect race could propagate instead of degrading to "missing" (advisory). Fixed: both docker calls are now inside the try.
  • Per-app throttle adds hot-path cost / one-shot sidecar churn (advisory). Acknowledged; the running-app path is a cheap ps+inspect no-op, and single-container apps (the production case) aren't affected.

Test adversary — no blocking, gaps addressed:

  • get_app_container_state never exercised against real docker. Fixed: test_get_app_container_state_reads_real_created_container installs an app and asserts the detector classifies its created container as needing a start (verified locally).
  • Mixed paused+exited detector case + _compose_up recovery paths untested. Fixed: added parametrized cases and test_start_app_recovers_stale_containers.

DevEx / readability — advisory only:

  • Duplication between start_app's paused branch and docker_unpause_app. Fixed: extracted _do_unpause.
  • Bare-string states, docstring narrating bug history. Fixed: ContainerState Literal, trimmed docstring.
  • start_app drops the docker_ prefix. Kept deliberately — it's the higher-level orchestrator vs. the thin docker_* CLI wrappers (matches the issue's proposed name).

CI fix: revive vs. uninstall race (commit 233858d)

First CI run surfaced a real regression the local run had misattributed to the port-80 collision: test_uninstall_running_app failed with DID NOT RAISE NotFound. Root cause — the new start_app reconcile recreates a missing/exited container for an app whose db status is still revivable, and the detached wake task (ensure_app_is_running) / always-on control tick could run compose up after the uninstall worker removed the containers, leaving an orphan that survives uninstall. The _REVIVABLE_STATUS gate alone couldn't close it (status read, then container removed, then compose up = TOCTOU).

Fix: a per-app asyncio.Lock (mirrors backup.py's BACKUP_IN_PROGESS_LOCK) held by both start_app and _uninstall_app, so a revive blocks until teardown finishes then reads status=None and skips. Added test_start_app_is_serialized_by_the_per_app_op_lock (falsifiable: fails without the lock).

Verification

Full unit suite green locally (57 app-lifecycle/compose/throttle tests incl. the new lock test). The two port-80 integration tests (test_app_starts_and_stops, test_uninstall_running_app) can't run on the dev VM (host port 80 held by infra); verified via CI, which has port 80 free.

CI: vendored type resync (commit 74e608f)

Controller main added ConfigOverrideKey/ConfigOverrideRequest and config_overrides_desired/applied after the last shard_core merge, so types-drift went red on every open PR. This branch carries the mechanical just get-types resync (only data_model/backend/shard_model.py, +26/-1, DO-NOT-MODIFY vendored) so it merges drift-free. Unrelated to the pause-revive fix — split into its own PR if preferred.

ClaydeCode and others added 6 commits July 24, 2026 08:17
The 5s throttle on the app-start primitive was keyed on nothing, so a start
of app A silently dropped a start of app B within the window. The wake path
worked around this by calling docker_unpause_app directly to dodge the
throttle. Keying the throttle on the call's positional args makes each app
throttle independently, so one revive primitive can serve every wake without
dropping cross-app calls.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
With pause_enabled, the wake-on-access path dispatched on the stored app
status: PAUSED woke via docker_unpause_app (compose unpause), everything else
via docker_start_app. When an app was exited (core-upgrade converge stop,
crash, OOM) while the database still said PAUSED, compose unpause errored
'container is not paused', the revive task crashed, and the app 502-looped
forever (issue #185).

Replace the status-driven dispatch with start_app: one idempotent primitive
that decides from the real container state, not the stored status. A paused
stack unfreezes, anything exited/created/missing is brought up with compose,
and an already-running stack is a no-op. When the stored status and the real
state disagree it still does the right thing and logs a warning, per review.
Both wake-on-access and the always-on control tick now call start_app only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Cover start_app's branches (exited-while-db-says-PAUSED revives via up -d
rather than crashing on unpause, genuine pause unfreezes, running is a no-op
that reconciles status, missing stack starts), get_app_container_state's
categorization, the wake path routing every status through start_app, and the
per-argument throttle.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Review panel found the new start_app dropped the old status allow-list, so the
always-on control tick could revive an app mid-uninstall/reinstall or in ERROR
and recreate orphaned containers. Restore the guard: start_app acts only on
STOPPED/RUNNING/DOWN/PAUSED, with the real-state dispatch inside the gate so the
#185 fix (revive an exited app the db still calls PAUSED) is preserved.

Also harden the revive: a partially-paused stack whose unpause errors now falls
back to up -d instead of crashing (which would relocate the 502-loop); the
docker-inspect call is inside the try so an inspect race degrades to 'missing'
rather than propagating. Extract _do_unpause to drop the duplication between
start_app and docker_unpause_app, type the container state as a Literal, and
trim the bug history out of the docstring (it lives in this commit).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…e detector

Cover the non-revivable-status gate, the unpause-fails fallback to up -d, the
stale-network/conflict recovery in _compose_up, a paused+exited mixed detector
case, and a real-docker test that get_app_container_state classifies a freshly
installed (created) container as needing a start.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
ClaydeCode and others added 2 commits July 24, 2026 08:59
Controller main added ConfigOverrideKey/ConfigOverrideRequest and the
config_overrides_desired/applied fields (operator-set config overrides,
incl. PAUSE_ENABLED). The vendored copy under data_model/backend went
stale, so the types-drift check fails on every open PR until a merging PR
carries the resync. Mechanical 'just get-types' output; DO-NOT-MODIFY.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The new start_app reconcile recreates a missing/exited container for an app
whose db status is still revivable. A wake-on-access revive (detached task
from ensure_app_is_running) or the always-on control tick could therefore
run compose up after the uninstall worker already removed the containers,
leaving an orphaned container that survives uninstall — test_uninstall_running_app
flakily failed with 'DID NOT RAISE NotFound'.

Add a per-app asyncio lock (mirrors backup.py's BACKUP_IN_PROGESS_LOCK) that
both start_app and _uninstall_app hold: a revive now blocks until teardown
finishes, then reads status=None and skips. Closes the TOCTOU the status
gate alone couldn't (status read, then container removed, then compose up).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@max-tet
max-tet merged commit fec1ae3 into main Jul 24, 2026
7 checks passed
@max-tet
max-tet deleted the fix/clayde/revive-exited-apps-on-access branch July 24, 2026 11:11
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

pause_enabled: exited apps never revive on access (unpause-only wake path 502-loops)

2 participants